Gehen Sie über traditionelle, beispielbasierte Tests hinaus. Dieser umfassende Leitfaden untersucht eigenschaftsbasiertes Testen in JavaScript mit fast-check und hilft Ihnen, mehr Fehler mit weniger Code zu finden.
Mehr als nur Beispiele: Ein tiefer Einblick in eigenschaftsbasiertes Testen in JavaScript
Als Softwareentwickler verbringen wir einen erheblichen Teil unserer Zeit mit dem Schreiben von Tests. Wir erstellen sorgfältig Unit-Tests, Integrationstests und End-to-End-Tests, um sicherzustellen, dass unsere Anwendungen robust, zuverlässig und frei von Regressionen sind. Das vorherrschende Paradigma dafür ist das beispielbasierte Testen. Wir denken uns eine bestimmte Eingabe aus und erwarten eine bestimmte Ausgabe. Die Eingabe `[1, 2, 3]` sollte die Ausgabe `6` erzeugen. Die Eingabe `"hello"` sollte zu `"HELLO"` werden. Aber dieser Ansatz hat eine stille, lauernde Schwäche: unsere eigene Vorstellungskraft.
Was ist, wenn Sie vergessen, mit einem leeren Array zu testen? Einer negativen Zahl? Einer Zeichenkette, die Unicode-Zeichen enthält? Einem tief verschachtelten Objekt? Jeder übersehene Grenzfall ist ein potenzieller Fehler, der nur darauf wartet, aufzutreten. Hier kommt das eigenschaftsbasierte Testen (Property-Based Testing, PBT) ins Spiel und bietet eine leistungsstarke Paradigmenverschiebung, die uns hilft, zuversichtlichere und widerstandsfähigere Software zu entwickeln.
Dieser umfassende Leitfaden führt Sie durch die Welt des eigenschaftsbasierten Testens in JavaScript. Wir werden untersuchen, was es ist, warum es so effektiv ist und wie Sie es noch heute in Ihren Projekten mit der beliebten Bibliothek `fast-check` implementieren können.
Die Grenzen des traditionellen, beispielbasierten Testens
Betrachten wir eine einfache Funktion, die ein Array von Zahlen sortiert. Mit einem gängigen Framework wie Jest oder Vitest könnte unser Test so aussehen:
// Eine einfache (und etwas naive) Sortierfunktion
function sortNumbers(arr) {
return [...arr].sort((a, b) => a - b);
}
// Ein typischer, beispielbasierter Test
test('sortNumbers sollte ein einfaches Array korrekt sortieren', () => {
const inputArray = [3, 1, 4, 1, 5, 9];
const expectedArray = [1, 1, 3, 4, 5, 9];
expect(sortNumbers(inputArray)).toEqual(expectedArray);
});
Dieser Test ist erfolgreich. Wir könnten noch ein paar weitere `it`- oder `test`-Blöcke hinzufügen:
- Ein bereits sortiertes Array.
- Ein Array mit negativen Zahlen.
- Ein Array mit einer Null.
- Ein leeres Array.
- Ein Array mit doppelten Zahlen (was wir bereits abgedeckt haben).
Wir fühlen uns gut. Wir haben die Grundlagen abgedeckt. Aber was haben wir übersehen? Was ist mit `[-0, 0]`? Was ist mit `[Infinity, -Infinity]`? Was ist mit einem sehr großen Array, das an Leistungsgrenzen oder seltsame Optimierungen der JavaScript-Engine stoßen könnte? Das grundlegende Problem ist, dass wir die Daten manuell auswählen. Unsere Tests sind nur so gut wie die Beispiele, die wir uns ausdenken können, und Menschen sind bekanntermaßen schlecht darin, sich all die seltsamen und wunderbaren Arten vorzustellen, wie Daten strukturiert sein können.
Beispielbasiertes Testen validiert, dass Ihr Code für einige wenige, handverlesene Szenarien funktioniert. Eigenschaftsbasiertes Testen validiert, dass Ihr Code für ganze Klassen von Eingaben funktioniert.
Was ist eigenschaftsbasiertes Testen? Ein Paradigmenwechsel
Eigenschaftsbasiertes Testen dreht den Spieß um. Anstatt zu behaupten, dass eine bestimmte Eingabe eine bestimmte Ausgabe liefert, definieren Sie eine allgemeine Eigenschaft Ihres Codes, die für jede gültige Eingabe zutreffen muss. Das Test-Framework generiert dann Hunderte oder Tausende von zufälligen Eingaben, um zu versuchen, Ihre Eigenschaft zu widerlegen.
Eine „Eigenschaft“ ist eine Invariante – eine übergeordnete Regel über das Verhalten Ihrer Funktion. Für unsere `sortNumbers`-Funktion könnten einige Eigenschaften sein:
- Idempotenz: Das Sortieren eines bereits sortierten Arrays sollte es nicht verändern. `sortNumbers(sortNumbers(arr))` sollte dasselbe sein wie `sortNumbers(arr)`.
- Längeninvarianz: Das sortierte Array sollte die gleiche Länge wie das ursprüngliche Array haben.
- Inhaltsinvarianz: Das sortierte Array sollte genau dieselben Elemente wie das ursprüngliche Array enthalten, nur in einer anderen Reihenfolge.
- Reihenfolge: Für zwei beliebige benachbarte Elemente im sortierten Array gilt `sorted[i] <= sorted[i+1]`.
Dieser Ansatz führt Sie vom Nachdenken über einzelne Beispiele zum Nachdenken über den grundlegenden Vertrag Ihres Codes. Dieser Sinneswandel ist unglaublich wertvoll für die Gestaltung besserer, vorhersagbarerer APIs.
Die Kernkomponenten von PBT
Ein Framework für eigenschaftsbasiertes Testen hat typischerweise zwei Schlüsselkomponenten:
- Generatoren (oder Arbitraries): Diese sind dafür verantwortlich, eine breite Palette von Zufallsdaten gemäß spezifizierten Typen (Ganzzahlen, Zeichenketten, Arrays von Objekten usw.) zu erzeugen. Sie sind intelligent genug, um nicht nur „Happy Path“-Daten zu generieren, sondern auch knifflige Grenzfälle wie leere Zeichenketten, `NaN`, `Infinity` und mehr.
- Shrinking: Dies ist die magische Zutat. Wenn das Framework eine Eingabe findet, die Ihre Eigenschaft widerlegt (d. h. einen Testfehler verursacht), meldet es nicht nur die große, zufällige Eingabe. Stattdessen versucht es systematisch, die kleinste und einfachste Eingabe zu finden, die den Fehler immer noch verursacht. Dies macht das Debugging exponentiell einfacher.
Erste Schritte: Implementierung von PBT mit `fast-check`
Obwohl es mehrere PBT-Bibliotheken im JavaScript-Ökosystem gibt, ist `fast-check` eine ausgereifte, leistungsstarke und gut gewartete Wahl. Es integriert sich nahtlos in gängige Test-Frameworks wie Jest, Vitest, Mocha und Jasmine.
Installation und Einrichtung
Fügen Sie zuerst `fast-check` zu den Entwicklungsabhängigkeiten Ihres Projekts hinzu. Wir gehen davon aus, dass Sie einen Test-Runner wie Jest verwenden.
npm install --save-dev fast-check jest
# oder
yarn add --dev fast-check jest
# oder
pnpm add -D fast-check jest
Ihr erster eigenschaftsbasierter Test
Schreiben wir unseren `sortNumbers`-Test mit `fast-check` neu. Wir werden die zuvor definierte „Reihenfolge“-Eigenschaft testen: Jedes Element sollte kleiner oder gleich dem sein, das ihm folgt.
import * as fc from 'fast-check';
// Die gleiche Funktion wie zuvor
function sortNumbers(arr) {
return [...arr].sort((a, b) => a - b);
}
test('die Ausgabe von sortNumbers sollte ein sortiertes Array sein', () => {
// 1. Die Eigenschaft beschreiben
fc.assert(
// 2. Die Arbitraries (Eingabegeneratoren) definieren
fc.property(fc.array(fc.integer()), (data) => {
// `data` ist ein zufällig generiertes Array von ganzen Zahlen
const sorted = sortNumbers(data);
// 3. Das Prädikat (die zu prüfende Eigenschaft) definieren
for (let i = 0; i < sorted.length - 1; ++i) {
if (sorted[i] > sorted[i + 1]) {
return false; // Die Eigenschaft ist widerlegt
}
}
return true; // Die Eigenschaft gilt für diese Eingabe
})
);
});
test('Sortieren sollte die Array-Länge nicht ändern', () => {
fc.assert(
fc.property(fc.array(fc.float()), (data) => {
const sorted = sortNumbers(data);
return sorted.length === data.length;
})
);
});
Lassen Sie uns das aufschlüsseln:
- `fc.assert()`: Dies ist der Runner. Er wird Ihre Eigenschaftsprüfung viele Male ausführen (standardmäßig 100).
- `fc.property()`: Dies definiert die Eigenschaft selbst. Es nimmt ein oder mehrere Arbitraries als Argumente, gefolgt von einer Prädikatfunktion.
- `fc.array(fc.integer())`: Dies ist unser Arbitrary. Es weist `fast-check` an, ein Array (`fc.array`) von Ganzzahlen (`fc.integer()`) zu generieren. `fast-check` wird automatisch Arrays unterschiedlicher Längen mit unterschiedlichen Ganzzahlwerten (positiv, negativ, null usw.) generieren.
- Das Prädikat: Die anonyme Funktion `(data) => { ... }` ist der Ort, an dem unsere Logik lebt. Sie empfängt die zufällig generierten Daten und muss `true` zurückgeben, wenn die Eigenschaft zutrifft, oder `false`, wenn sie verletzt wird. `fast-check` unterstützt auch Prädikatfunktionen, die bei einem Fehler einen Fehler auslösen, was sich gut in die `expect`-Assertions von Jest integriert.
Jetzt haben wir anstelle eines Tests mit einem handverlesenen Array einen Test, der unsere Sortierlogik bei jeder Ausführung unserer Suite gegen 100 verschiedene, automatisch generierte Arrays überprüft. Wir haben unsere Testabdeckung mit nur wenigen Zeilen Code massiv erhöht.
Erkundung von Arbitraries: Die richtigen Daten generieren
Die Stärke von PBT liegt in seiner Fähigkeit, vielfältige und herausfordernde Daten zu generieren. `fast-check` bietet einen reichhaltigen Satz von Arbitraries, um fast jede erdenkliche Datenstruktur abzudecken.
Grundlegende Arbitraries
Dies sind die Bausteine für Ihre Datengenerierung.
- `fc.integer()`, `fc.float()`, `fc.bigInt()`: Für Zahlen. Sie können eingeschränkt werden, z. B. `fc.integer({ min: 0, max: 100 })`.
- `fc.string()`, `fc.asciiString()`, `fc.unicodeString()`: Für Zeichenketten verschiedener Zeichensätze.
- `fc.boolean()`: Für `true` oder `false`.
- `fc.constant(value)`: Gibt immer denselben Wert zurück. Nützlich zum Mischen mit `fc.oneof`.
- `fc.constantFrom(val1, val2, ...)`: Gibt einen der bereitgestellten konstanten Werte zurück.
Komplexe und zusammengesetzte Arbitraries
Sie können grundlegende Arbitraries kombinieren, um komplexe Datenstrukturen zu erstellen.
- `fc.array(arbitrary, constraints)`: Generiert ein Array von Elementen, die vom bereitgestellten Arbitrary erstellt werden. Sie können die `minLength` und `maxLength` einschränken.
- `fc.tuple(arb1, arb2, ...)`: Generiert ein Array fester Länge, bei dem jedes Element einen spezifischen, unterschiedlichen Typ hat.
- `fc.object(shape)`: Generiert Objekte mit einer definierten Struktur. Beispiel: `fc.object({ id: fc.uuidV(4), name: fc.string() })`.
- `fc.oneof(arb1, arb2, ...)`: Generiert einen Wert aus einem der bereitgestellten Arbitraries. Dies ist hervorragend zum Testen von Funktionen, die mehrere Datentypen behandeln (z. B. `string | number`).
- `fc.record({ key: arb, value: arb })`: Generiert Objekte zur Verwendung als Dictionaries oder Maps, bei denen Schlüssel und Werte aus Arbitraries generiert werden.
Erstellen von benutzerdefinierten Arbitraries mit `map` und `chain`
Manchmal benötigen Sie Daten, die nicht in eine Standardform passen. `fast-check` ermöglicht es Ihnen, Ihre eigenen Arbitraries zu erstellen, indem Sie bestehende transformieren.
Verwendung von `.map()`
Die `.map()`-Methode transformiert die Ausgabe eines Arbitrary in etwas anderes. Erstellen wir zum Beispiel ein Arbitrary, das nicht-leere Zeichenketten generiert.
const nonEmptyStringArb = fc.string({ minLength: 1 });
// Oder durch Transformation eines Arrays von Zeichen
const nonAStringArb = fc.array(fc.char().filter(c => c !== 'a'))
.map(chars => chars.join(''));
Verwendung von `.chain()`
Die `.chain()`-Methode ist leistungsfähiger. Sie ermöglicht es Ihnen, ein neues Arbitrary basierend auf dem generierten Wert eines vorherigen zu erstellen. Dies ist unerlässlich für die Erstellung korrelierter Daten.
Stellen Sie sich vor, Sie müssen ein Array und dann einen gültigen Index für dasselbe Array generieren. Sie können dies nicht mit zwei separaten Arbitraries tun, da der Index außerhalb der Grenzen liegen könnte. `.chain()` löst dieses Problem perfekt.
// Generiere ein Array und einen gültigen Index dafür
const arrayAndValidIndexArb = fc.array(fc.anything()).chain(arr => {
// Basierend auf dem generierten Array `arr` wird ein neues Arbitrary für den Index erstellt
const indexArb = fc.integer({ min: 0, max: arr.length - 1 });
// Gib ein Tupel aus dem Array und dem generierten Index zurück
return fc.tuple(fc.constant(arr), indexArb);
});
// Verwendung in einem Test
test('slicing bei einem gültigen Index sollte funktionieren', () => {
fc.assert(
fc.property(arrayAndValidIndexArb, ([arr, index]) => {
// Sowohl `arr` als auch `index` sind garantiert kompatibel
const sliced = arr.slice(0, index);
expect(sliced.length).toBe(index);
})
);
});
Die Macht des Shrinkings: Debugging leicht gemacht
Das überzeugendste Merkmal des eigenschaftsbasierten Testens ist das Shrinking. Um es in Aktion zu sehen, erstellen wir eine absichtlich fehlerhafte Funktion.
// Diese Funktion schlägt fehl, wenn das Eingabe-Array die Zahl 42 enthält
function sumWithoutBug(arr) {
if (arr.includes(42)) {
throw new Error('Diese Zahl ist nicht erlaubt!');
}
return arr.reduce((acc, val) => acc + val, 0);
}
test('sumWithoutBug sollte Zahlen summieren', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (data) => {
sumWithoutBug(data);
})
);
});
Wenn Sie diesen Test ausführen, wird `fast-check` mit ziemlicher Sicherheit einen fehlschlagenden Fall finden. Aber es wird nicht das erste zufällige Array melden, das es gefunden hat, was so etwas wie `[-1024, 500, 42, 987, -2000]` sein könnte. Ein solcher Fehlerbericht ist nicht sehr hilfreich. Sie müssten ihn manuell überprüfen, um die problematische `42` zu finden.
Stattdessen wird der Shrinker von `fast-check` aktiv. Er wird den Fehler sehen und beginnen, die Eingabe zu vereinfachen:
- Kann ich ein Element entfernen? Versuche `[500, 42, 987, -2000]`. Schlägt immer noch fehl. Gut.
- Kann ich noch eines entfernen? Versuche `[42, 987, -2000]`. Schlägt immer noch fehl.
- ...und so weiter, bis es keine weiteren Elemente mehr entfernen kann, ohne dass der Test erfolgreich ist.
- Es wird auch versuchen, die Zahlen zu verkleinern. Kann `42` zu `0` werden? Nein, der Test ist erfolgreich. Kann es `41` sein? Test ist erfolgreich. Es grenzt es ein.
Der endgültige Fehlerbericht wird etwa so aussehen:
Error: Property failed after 15 tests
{ seed: 12345678, path: "14", endOnFailure: true }
Counterexample: [[42]]
Shrunk 5 time(s)
Got error: This number is not allowed!
Es teilt Ihnen die exakte, minimale Eingabe mit, die den Fehler verursacht hat: ein Array, das nur die Zahl `[42]` enthält. Dies weist Sie sofort auf die Fehlerquelle hin und spart Ihnen immense Zeit und Mühe beim Debugging.
Praktische PBT-Strategien und Beispiele aus der Praxis
PBT ist nicht nur für mathematische Funktionen geeignet. Es ist ein vielseitiges Werkzeug, das in vielen Bereichen der Softwareentwicklung angewendet werden kann.
Eigenschaft: Inverse Funktionen
Wenn Sie eine Funktion haben, die Daten kodiert, und eine andere, die sie dekodiert, sind sie Inverse voneinander. Eine großartige Eigenschaft zum Testen ist, dass das Dekodieren eines kodierten Wertes immer den ursprünglichen Wert zurückgeben sollte.
// `encode` und `decode` könnten für base64, URI-Komponenten oder benutzerdefinierte Serialisierung sein
function encode(obj) { return JSON.stringify(obj); }
function decode(str) { return JSON.parse(str); }
test('decode(encode(x)) sollte gleich x sein', () => {
// `fc.jsonValue()` generiert jeden gültigen JSON-Wert: Strings, Zahlen, Objekte, Arrays
fc.assert(
fc.property(fc.jsonValue(), (originalValue) => {
const encoded = encode(originalValue);
const decoded = decode(encoded);
expect(decoded).toEqual(originalValue);
})
);
});
Eigenschaft: Idempotenz
Eine Operation ist idempotent, wenn ihre mehrfache Anwendung den gleichen Effekt hat wie ihre einmalige Anwendung. `f(f(x)) === f(x)`. Dies ist eine entscheidende Eigenschaft für Dinge wie Datenbereinigungsfunktionen oder `DELETE`-Endpunkte in einer REST-API.
// Eine Funktion, die führende/nachfolgende Leerzeichen entfernt und mehrere Leerzeichen zusammenfasst
function normalizeWhitespace(text) {
return text.trim().replace(/\s+/g, ' ');
}
test('normalizeWhitespace sollte idempotent sein', () => {
fc.assert(
fc.property(fc.string(), (originalString) => {
const once = normalizeWhitespace(originalString);
const twice = normalizeWhitespace(once);
expect(twice).toBe(once);
})
);
});
Eigenschaft: Zustandsbasiertes (modellbasiertes) Testen
Dies ist eine fortgeschrittenere, aber unglaublich leistungsstarke Technik zum Testen von Systemen mit internem Zustand, wie z. B. einer UI-Komponente, einem Warenkorb oder einer Zustandsmaschine. Die Idee ist, ein einfaches Softwaremodell Ihres Systems und eine Reihe von Befehlen zu erstellen, die sowohl gegen Ihr Modell als auch gegen die reale Implementierung ausgeführt werden können. Die Eigenschaft ist, dass der Zustand des Modells und der Zustand des realen Systems immer übereinstimmen sollten.
`fast-check` stellt für diesen Zweck `fc.commands` zur Verfügung. Modellieren wir einen einfachen Zähler:
// Die echte Implementierung
class Counter {
constructor() { this.count = 0; }
increment() { this.count++; }
decrement() { this.count--; }
get() { return this.count; }
}
// Die Befehle für fast-check
const incrementCmd = fc.command(
// check: eine Funktion, um zu prüfen, ob der Befehl auf dem Modell ausgeführt werden kann
(model) => true,
// run: eine Funktion, um den Befehl sowohl auf dem Modell als auch auf dem echten System auszuführen
(model, real) => {
model.count++;
real.increment();
expect(real.get()).toBe(model.count);
}
);
const decrementCmd = fc.command(
(model) => true,
(model, real) => {
model.count--;
real.decrement();
expect(real.get()).toBe(model.count);
}
);
test('Counter sollte sich dem Modell entsprechend verhalten', () => {
fc.assert(
fc.property(fc.commands([incrementCmd, decrementCmd]), (cmds) => {
const model = { count: 0 };
const real = new Counter();
fc.modelRun(() => ({ model, real }), cmds);
})
);
});
In diesem Test generiert `fast-check` eine zufällige Sequenz von `increment`- und `decrement`-Befehlen, führt sie sowohl gegen unser einfaches Objektmodell als auch gegen die echte `Counter`-Klasse aus und stellt sicher, dass sie niemals voneinander abweichen. Dies kann subtile Fehler in komplexer zustandsbasierter Logik aufdecken, die mit beispielbasiertem Testen nahezu unmöglich zu finden wären.
Wann man eigenschaftsbasiertes Testen NICHT verwenden sollte
PBT ist eine leistungsstarke Ergänzung zu Ihrem Test-Toolkit, aber es ist kein Ersatz für alle anderen Testformen. Es ist keine Wunderwaffe.
Beispielbasiertes Testen ist oft besser, wenn:
- Spezifische, bekannte Geschäftsregeln getestet werden. Wenn eine Steuerberechnung für eine bestimmte Eingabe genau `$10.53` ergeben muss, ist ein einfacher beispielbasierter Test klarer und direkter. Dies ist ein Regressionstest für eine bekannte Anforderung.
- Die „Eigenschaft“ nur „Eingabe X erzeugt Ausgabe Y“ ist. Wenn es keine übergeordnete, verallgemeinerbare Regel über das Verhalten der Funktion gibt, kann das Erzwingen eines eigenschaftsbasierten Tests komplexer sein, als es sich lohnt.
- Benutzeroberflächen auf visuelle Korrektheit getestet werden. Während Sie die Zustandslogik einer UI-Komponente mit PBT testen können, wird die Überprüfung eines bestimmten visuellen Layouts oder Stils besser durch Snapshot-Tests oder visuelle Regressionstools gehandhabt.
Die effektivste Strategie ist ein hybrider Ansatz. Verwenden Sie eigenschaftsbasierte Tests, um Ihre Algorithmen, Datentransformationen und zustandsbasierte Logik gegen ein Universum von Möglichkeiten zu Stresstesten. Verwenden Sie traditionelle beispielbasierte Tests, um spezifische, kritische Geschäftsanforderungen festzulegen und Regressionen bei bekannten Fehlern zu verhindern.
Fazit: Denken Sie in Eigenschaften, nicht nur in Beispielen
Eigenschaftsbasiertes Testen fördert eine tiefgreifende Veränderung in der Art und Weise, wie wir über Korrektheit nachdenken. Es zwingt uns, von einzelnen Beispielen zurückzutreten und die grundlegenden Prinzipien und Verträge zu betrachten, die unser Code einhalten sollte. Dadurch können wir:
- Überraschende Grenzfälle aufdecken, an deren Test wir nie gedacht hätten.
- Viel höheres Vertrauen in die Robustheit unseres Codes gewinnen.
- Ausdrucksstärkere Tests schreiben, die das Verhalten unseres Systems dokumentieren, anstatt nur seine Ausgabe für einige wenige Eingaben.
- Die Debug-Zeit drastisch reduzieren, dank der Macht des Shrinkings.
Die Einführung von eigenschaftsbasiertem Testen mag sich anfangs ungewohnt anfühlen, aber die Investition lohnt sich. Fangen Sie klein an. Wählen Sie eine reine Funktion in Ihrer Codebasis aus – eine, die Datenumwandlungen oder eine komplexe Berechnung durchführt – und versuchen Sie, eine Eigenschaft dafür zu definieren. Fügen Sie Ihrem nächsten Projekt einen eigenschaftsbasierten Test hinzu. Wenn Sie erleben, wie er seinen ersten nicht-trivialen Fehler findet, werden Sie von seiner Fähigkeit überzeugt sein, bessere und zuverlässigere Software für ein globales Publikum zu entwickeln.
Weitere Ressourcen
- Offizielle Dokumentation von fast-check
- Understanding Property-Based Testing von Scott Wlaschin (eine klassische, sprachunabhängige Einführung)